Linux 多进程开发:fork、exec 与进程回收
Linux 上创建进程、让它执行新程序、再正确地等它退出,是多进程编程的基本功。本文写给写 Linux C/C++ 程序的开发者,讲清这条链路上的核心机制:fork 的返回值语义与写时复制、exec 函数族的命名规律、exit 与 _exit 的缓冲区别、孤儿进程与僵尸进程的成因,以及 wait/waitpid 的回收姿势。进程间通信(管道、信号、共享内存)不在本文范围,见文末系列链接。
进程查看命令
ps aux # 查看所有进程快照(a 所有终端 u 用户格式 x 无终端进程)
ps ajx # job 格式,可查看进程组、会话信息
top # 实时查看进程信息
kill -l # 列出所有信号
kill -9 <pid> # 向进程发送信号
<命令> & # 后台运行 fork:创建子进程
#include <unistd.h>
pid_t fork(void); fork 的特殊之处是返回两次:父进程中返回子进程的 PID,子进程中返回 0,失败时父进程返回 -1 并设置 errno。区分父子公司代码段的依据就是这个返回值:
pid_t pid = fork();
if (pid > 0) {
// 父进程:pid 是子进程的 PID
} else if (pid == 0) {
// 子进程
} 父子进程 fork 之后的差异集中在 PCB 少量字段上(PID、PPID、信号集);用户区数据、文件描述符表在刚 fork 完时内容相同。对变量的"共享"要分阶段看:刚 fork 完两个进程的内存内容一致;任何一方修改数据后各自独立——这就是读时共享、写时复制(copy-on-write)。fork 时内核并不立即复制整个地址空间,而是让父子共享同一份物理页并标记为只读,真正发生写入时才复制对应页面,各进程从此拥有各自的地址空间。
一个常被忽略的事实:fork 后父子进程的文件描述符指向同一个文件表项,共享文件偏移量,引用计数增加。
exec 函数族:替换进程映像
exec 族函数按指定的文件找到可执行程序,用它替换当前进程的内容——代码段、数据段、堆栈全部被新程序取代,只保留进程 ID 等少数信息。因此调用成功后不会返回(原来的代码已经不存在了);只有失败才返回 -1,从原调用点继续执行。
六个常用函数的命名由后缀组合而成:
#include <unistd.h>
int execl(const char *path, const char *arg, ..., NULL);
int execlp(const char *file, const char *arg, ..., NULL);
int execle(const char *path, const char *arg, ..., NULL, char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]); // GNU 扩展
// execve 是系统调用,其余是 libc 封装 | 后缀 | 含义 |
|---|---|
l(list) | 参数以可变参数列表逐个传入,以 NULL 结尾(哨兵) |
v(vector) | 参数打包成指针数组传入 |
p(path) | 按 PATH 环境变量搜索可执行文件,可只给文件名 |
e(environment) | 显式传入环境变量数组 |
典型用法是在子进程里 exec,父进程继续做自己的事:
pid_t pid = fork();
if (pid == 0) {
execlp("ps", "ps", "aux", NULL); // 参数列表:程序名 + 实参 + NULL
perror("execlp"); // 只有失败才会走到这里
exit(1);
}
// 父进程代码 参数列表的第一个参数习惯上写程序名(对应 main 的 argv[0]),实际参数从第二项开始。
GDB 调试多进程程序
GDB 默认只跟随一个进程(父进程),子进程直接跑掉。两个开关控制这个行为:
(gdb) set follow-fork-mode parent # 跟随父进程(默认)
(gdb) set follow-fork-mode child # 跟随子进程
(gdb) set detach-on-fork on # 其他进程脱离调试继续运行(默认)
(gdb) set detach-on-fork off # 其他进程被 GDB 挂起 detach-on-fork off 时可以管理多个被调试进程:
(gdb) info inferiors # 查看所有被调试进程
(gdb) inferior 2 # 切换到编号为 2 的进程
(gdb) detach inferiors 2 # 让进程 2 脱离 GDB 进程退出:exit 与 _exit
#include <stdlib.h>
void exit(int status); // C 标准库
#include <unistd.h>
void _exit(int status); // 系统调用 status 是退出状态,父进程回收时可以取到。两者的区别在缓冲区:exit 退出前会刷新 stdio 的用户态缓冲区——printf("world") 这种不带换行、还留在缓冲区里的内容会被刷出;_exit 直接进入系统调用,不刷用户态缓冲,未输出的内容丢失。
孤儿进程与僵尸进程
孤儿进程:父进程先退出,子进程还在运行。内核会把孤儿进程交由 init 进程(现代发行版上通常是 systemd;设置了 subreaper 的进程树中是最近的 subreaper)收养,由它循环 wait 已退出的子进程。孤儿进程没有危害。
僵尸进程:每个进程退出时都会释放用户区资源,但内核区的 PCB 无法自己释放,需要父进程调用 wait/waitpid 来收。子进程已终止、父进程尚未回收时,残留的 PCB 信息就留在内核里,进程处于僵尸状态(ps 中 STAT 为 Z,[proc] <defunct>):
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
13038 17195 17195 13038 pts/0 17195 S+ 500 0:00 ./zombie
17195 17196 17195 13038 pts/0 17195 Z+ 500 0:00 [zombie] <defunct> 僵尸进程不能被 kill -9 杀死——它已经死了,信号无处投递。危害在于进程号被持续占用:系统可用 PID 有限,大量僵尸会耗尽进程号,导致无法创建新进程。避免方法就是父进程及时回收,或者父进程退出让孤儿被 init 收养代为回收。
wait 与 waitpid 回收
父进程通过 wait/waitpid 获取子进程退出状态并彻底清理 PCB:
#include <sys/wait.h>
pid_t wait(int *wstatus);
// 阻塞等待任意一个子进程结束并回收,成功返回被回收的子进程 PID
// 没有子进程(或都已回收)时返回 -1
pid_t waitpid(pid_t pid, int *wstatus, int options);
// 回收指定的子进程,可设置非阻塞 waitpid 的 pid 参数:> 0 回收指定 PID 的子进程;= 0 回收本进程组任一子进程;= -1 回收任意子进程(最常用,等价于 wait);< -1 回收进程组 ID 等于其绝对值的任一子进程。options 传 0 阻塞,传 WNOHANG 非阻塞——非阻塞时返回 0 表示还有子进程活着,返回 -1 表示没有子进程了。
一次 wait/waitpid 调用只清理一个子进程,多个子进程要循环调用。
wstatus 用一组宏解读(都是宏不是函数):
WIFEXITED(status) // 非 0:子进程正常退出
WEXITSTATUS(status) // 配合上宏,取退出状态码(exit 的参数)
WIFSIGNALED(status) // 非 0:子进程被信号异常终止
WTERMSIG(status) // 配合上宏,取导致终止的信号编号
WIFSTOPPED(status) // 非 0:子进程处于暂停状态
WSTOPSIG(status) // 配合上宏,取使其暂停的信号编号
WIFCONTINUED(status) // 非 0:暂停后已恢复运行 父进程用 WNOHANG 轮询回收全部子进程的典型骨架:
while (1) {
int st;
int ret = waitpid(-1, &st, WNOHANG);
if (ret == -1) {
break; // 没有子进程了,退出
} else if (ret == 0) {
continue; // 还有子进程在运行,做自己的事
}
if (WIFEXITED(st)) {
printf("子进程 %d 正常退出,状态码 %d\n", ret, WEXITSTATUS(st));
} else if (WIFSIGNALED(st)) {
printf("子进程 %d 被信号 %d 终止\n", ret, WTERMSIG(st));
}
} 更优雅的异步方案是捕捉 SIGCHLD 信号、在处理函数里循环 waitpid,见 Linux 信号机制:产生、阻塞与捕捉。
要点
- fork 返回两次:父进程拿子进程 PID,子进程拿 0,靠返回值区分代码路径
- fork 后内存读时共享、写时复制;文件描述符共享文件表项与偏移量
- exec 成功不返回,失败返回 -1;后缀 l/v 决定参数形式,p 决定是否搜 PATH,e 决定是否传环境
- exit 刷 stdio 缓冲,_exit 不刷;退出状态由父进程经 wait 取到
- 僵尸进程是"已退出但 PCB 未回收",kill -9 无效,只能父进程 wait 或父进程退出触发收养
- wait 阻塞回收任意子进程,waitpid 可指定、可非阻塞;一次调用只清一个,多个子进程用循环